Micron Document
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
| SparkN0de-git | SparkN0de |
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------


Commit d3dfa648a321ceb626e8c2235ab5fd0ad33026b8


Parents : 66064ca
Author : Ivan <ivan@quad4.io>
Signature : Signature validation error
Date : 2026-06-03T08:07:54-05:00

feat(android): implement AndroidStorageManager for handling internal and external storage, including migration and setup options

Changes
Diff

diff --git a/android/app/src/main/java/com/meshchatx/AndroidStorageManager.java b/android/app/src/main/java/com/meshchatx/AndroidStorageManager.java
new file mode 100644
index 00000000..648efd30
--- /dev/null
+++ b/android/app/src/main/java/com/meshchatx/AndroidStorageManager.java
@@ -0,0 +1,264 @@
+package com.meshchatx;
+
+import android.content.Context;
+import android.content.SharedPreferences;
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.nio.channels.FileChannel;
+import org.json.JSONObject;
+
+/**
+ * Resolves MeshChatX data roots on Android (app-internal vs app-specific external storage)
+ * and runs one-shot copy migrations before the embedded Python server starts.
+ */
+public final class AndroidStorageManager {
+ public static final String PREFS_NAME = "meshchatx";
+ public static final String PREF_STORAGE_MODE = "android_storage_mode";
+ public static final String PREF_SETUP_COMPLETED = "android_storage_setup_completed";
+ public static final String PREF_PENDING_COPY_EXTERNAL = "android_storage_pending_copy_external";
+ public static final String PREF_UPGRADE_PROMPT_VERSION = "android_storage_upgrade_prompt_version";
+
+ public static final String MODE_INTERNAL = "internal";
+ public static final String MODE_EXTERNAL = "external";
+ public static final String MESHCHAT_DIR = "meshchatx";
+
+ private AndroidStorageManager() {}
+
+ public static SharedPreferences prefs(Context context) {
+ return context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE);
+ }
+
+ public static File internalBaseDir(Context context) {
+ return context.getFilesDir();
+ }
+
+ public static File externalBaseDir(Context context) {
+ File external = context.getExternalFilesDir(null);
+ return external != null ? external : context.getFilesDir();
+ }
+
+ public static File baseDirForMode(Context context, String mode) {
+ if (MODE_EXTERNAL.equals(mode)) {
+ return externalBaseDir(context);
+ }
+ return internalBaseDir(context);
+ }
+
+ public static File meshchatRoot(Context context, String mode) {
+ return new File(baseDirForMode(context, mode), MESHCHAT_DIR);
+ }
+
+ public static String getConfiguredMode(Context context) {
+ return prefs(context).getString(PREF_STORAGE_MODE, null);
+ }
+
+ public static void setConfiguredMode(Context context, String mode) {
+ if (!MODE_INTERNAL.equals(mode) && !MODE_EXTERNAL.equals(mode)) {
+ throw new IllegalArgumentException("Invalid storage mode: " + mode);
+ }
+ prefs(context).edit().putString(PREF_STORAGE_MODE, mode).apply();
+ }
+
+ public static void markSetupCompleted(Context context) {
+ prefs(context).edit().putBoolean(PREF_SETUP_COMPLETED, true).apply();
+ }
+
+ public static boolean isSetupCompleted(Context context) {
+ return prefs(context).getBoolean(PREF_SETUP_COMPLETED, false);
+ }
+
+ public static void scheduleCopyToExternal(Context context) {
+ prefs(context)
+ .edit()
+ .putBoolean(PREF_PENDING_COPY_EXTERNAL, true)
+ .apply();
+ }
+
+ public static boolean isPendingCopyToExternal(Context context) {
+ return prefs(context).getBoolean(PREF_PENDING_COPY_EXTERNAL, false);
+ }
+
+ public static void clearPendingCopyToExternal(Context context) {
+ prefs(context).edit().remove(PREF_PENDING_COPY_EXTERNAL).apply();
+ }
+
+ public static void markUpgradePromptSeen(Context context, int versionCode) {
+ prefs(context).edit().putInt(PREF_UPGRADE_PROMPT_VERSION, versionCode).apply();
+ }
+
+ public static int getUpgradePromptSeenVersion(Context context) {
+ return prefs(context).getInt(PREF_UPGRADE_PROMPT_VERSION, 0);
+ }
+
+ public static boolean hasMeshchatData(File root) {
+ if (root == null || !root.isDirectory()) {
+ return false;
+ }
+ File storage = new File(root, "storage");
+ if (directoryHasEntries(storage)) {
+ return true;
+ }
+ File reticulum = new File(root, "reticulum");
+ if (directoryHasEntries(reticulum)) {
+ return true;
+ }
+ return false;
+ }
+
+ private static boolean directoryHasEntries(File dir) {
+ if (!dir.isDirectory()) {
+ return false;
+ }
+ String[] names = dir.list();
+ return names != null && names.length > 0;
+ }
+
+ public static boolean hasInternalMeshchatData(Context context) {
+ return hasMeshchatData(meshchatRoot(context, MODE_INTERNAL));
+ }
+
+ public static boolean hasExternalMeshchatData(Context context) {
+ return hasMeshchatData(meshchatRoot(context, MODE_EXTERNAL));
+ }
+
+ /**
+ * Active mode used to start the embedded server on this process launch.
+ */
+ public static String resolveActiveMode(Context context) {
+ String configured = getConfiguredMode(context);
+ if (configured != null) {
+ return configured;
+ }
+ if (hasInternalMeshchatData(context)) {
+ return MODE_INTERNAL;
+ }
+ return MODE_EXTERNAL;
+ }
+
+ public static File resolveActiveBaseDir(Context context) {
+ return baseDirForMode(context, resolveActiveMode(context));
+ }
+
+ /**
+ * Copy internal meshchatx tree to external when requested, then pin mode to external.
+ */
+ public static void runPendingMigration(Context context) throws IOException {
+ if (!isPendingCopyToExternal(context)) {
+ return;
+ }
+ File src = meshchatRoot(context, MODE_INTERNAL);
+ File dst = meshchatRoot(context, MODE_EXTERNAL);
+ if (hasMeshchatData(src)) {
+ copyDirectory(src, dst);
+ }
+ setConfiguredMode(context, MODE_EXTERNAL);
+ markSetupCompleted(context);
+ clearPendingCopyToExternal(context);
+ int versionCode = readAppVersionCode(context);
+ if (versionCode > 0) {
+ markUpgradePromptSeen(context, versionCode);
+ }
+ }
+
+ private static int readAppVersionCode(Context context) {
+ try {
+ return context.getPackageManager().getPackageInfo(context.getPackageName(), 0).versionCode;
+ } catch (Exception e) {
+ return 0;
+ }
+ }
+
+ public static boolean externalStorageAvailable(Context context) {
+ File external = context.getExternalFilesDir(null);
+ return external != null;
+ }
+
+ public static boolean needsSetupChoice(Context context) {
+ if (getConfiguredMode(context) != null) {
+ return false;
+ }
+ return !isSetupCompleted(context) && !hasInternalMeshchatData(context);
+ }
+
+ public static boolean needsUpgradePrompt(Context context, int appVersionCode) {
+ if (getConfiguredMode(context) != null) {
+ return false;
+ }
+ if (!hasInternalMeshchatData(context)) {
+ return false;
+ }
+ if (isPendingCopyToExternal(context)) {
+ return false;
+ }
+ return getUpgradePromptSeenVersion(context) < appVersionCode;
+ }
+
+ public static void keepInternalAndDismissUpgrade(Context context, int appVersionCode) {
+ setConfiguredMode(context, MODE_INTERNAL);
+ markSetupCompleted(context);
+ markUpgradePromptSeen(context, appVersionCode);
+ }
+
+ public static String statusJson(Context context, int appVersionCode) throws Exception {
+ String configured = getConfiguredMode(context);
+ String active = resolveActiveMode(context);
+ JSONObject json = new JSONObject();
+ json.put("platform", "android");
+ json.put("configured_mode", configured == null ? JSONObject.NULL : configured);
+ json.put("active_mode", active);
+ json.put("internal_path", meshchatRoot(context, MODE_INTERNAL).getAbsolutePath());
+ json.put("external_path", meshchatRoot(context, MODE_EXTERNAL).getAbsolutePath());
+ json.put("active_path", meshchatRoot(context, active).getAbsolutePath());
+ json.put("has_internal_data", hasInternalMeshchatData(context));
+ json.put("has_external_data", hasExternalMeshchatData(context));
+ json.put("needs_setup_choice", needsSetupChoice(context));
+ json.put("needs_upgrade_prompt", needsUpgradePrompt(context, appVersionCode));
+ json.put("pending_copy", isPendingCopyToExternal(context));
+ json.put("external_available", externalStorageAvailable(context));
+ json.put("setup_completed", isSetupCompleted(context));
+ return json.toString();
+ }
+
+ public static void copyDirectory(File source, File target) throws IOException {
+ if (!source.exists()) {
+ return;
+ }
+ if (source.isFile()) {
+ copyFile(source, target);
+ return;
+ }
+ if (!source.isDirectory()) {
+ return;
+ }
+ if (!target.exists() && !target.mkdirs()) {
+ throw new IOException("mkdirs failed: " + target.getAbsolutePath());
+ }
+ File[] children = source.listFiles();
+ if (children == null) {
+ return;
+ }
+ for (File child : children) {
+ copyDirectory(child, new File(target, child.getName()));
+ }
+ }
+
+ public static void copyFile(File source, File target) throws IOException {
+ File parent = target.getParentFile();
+ if (parent != null && !parent.exists() && !parent.mkdirs()) {
+ throw new IOException("mkdirs failed: " + parent.getAbsolutePath());
+ }
+ try (FileInputStream in = new FileInputStream(source);
+ FileOutputStream out = new FileOutputStream(target, false);
+ FileChannel inChannel = in.getChannel();
+ FileChannel outChannel = out.getChannel()) {
+ long size = inChannel.size();
+ long transferred = 0L;
+ while (transferred < size) {
+ transferred += inChannel.transferTo(transferred, size - transferred, outChannel);
+ }
+ outChannel.force(true);
+ }
+ }
+}

diff --git a/android/app/src/main/java/com/meshchatx/MainActivity.java b/android/app/src/main/java/com/meshchatx/MainActivity.java
index 91b50666..fc102c3c 100644
--- a/android/app/src/main/java/com/meshchatx/MainActivity.java
+++ b/android/app/src/main/java/com/meshchatx/MainActivity.java
@@ -434,6 +434,15 @@ public class MainActivity extends AppCompatActivity {
}
);
+ try {
+ AndroidStorageManager.runPendingMigration(this);
+ } catch (IOException migrationError) {
+ showStartupError(
+ "MeshChatX could not copy data to file-manager storage:\n" + migrationError.getMessage()
+ );
+ return;
+ }
+
startMeshChatServer();
scheduleConnectionRetry("Connecting to local server...");
}
@@ -615,7 +624,7 @@ public class MainActivity extends AppCompatActivity {
new Thread(() -> {
try {
Python py = Python.getInstance();
- String appFilesDir = getFilesDir().getAbsolutePath();
+ String appFilesDir = AndroidStorageManager.resolveActiveBaseDir(MainActivity.this).getAbsolutePath();
py.getModule("meshchat_wrapper").callAttr("start_server", SERVER_PORT, appFilesDir);
} catch (Exception e) {
final String stack = toStackTrace(e);
@@ -1215,6 +1224,53 @@ public class MainActivity extends AppCompatActivity {
public void cancelIncomingCallNotification() {
AndroidNotificationBridge.cancelIncomingCallNotification();
}
+
+ @JavascriptInterface
+ public String getAndroidStorageStatus() {
+ try {
+ int versionCode = activity.getPackageManager().getPackageInfo(activity.getPackageName(), 0).versionCode;
+ return AndroidStorageManager.statusJson(activity, versionCode);
+ } catch (PackageManager.NameNotFoundException e) {
+ return "{}";
+ } catch (Exception e) {
+ return "{}";
+ }
+ }
+
+ @JavascriptInterface
+ public void setAndroidStorageMode(String mode) {
+ AndroidStorageManager.setConfiguredMode(activity, mode);
+ }
+
+ @JavascriptInterface
+ public void markAndroidStorageSetupCompleted() {
+ AndroidStorageManager.markSetupCompleted(activity);
+ }
+
+ @JavascriptInterface
+ public void scheduleCopyToExternalAndRestart() {
+ AndroidStorageManager.scheduleCopyToExternal(activity);
+ }
+
+ @JavascriptInterface
+ public void keepInternalStorageAndDismiss() {
+ try {
+ int versionCode =
+ activity.getPackageManager().getPackageInfo(activity.getPackageName(), 0).versionCode;
+ AndroidStorageManager.keepInternalAndDismissUpgrade(activity, versionCode);
+ } catch (PackageManager.NameNotFoundException ignored) {
+ AndroidStorageManager.setConfiguredMode(activity, AndroidStorageManager.MODE_INTERNAL);
+ AndroidStorageManager.markSetupCompleted(activity);
+ }
+ }
+
+ @JavascriptInterface
+ public void restartApp() {
+ activity.runOnUiThread(() -> {
+ activity.finishAffinity();
+ android.os.Process.killProcess(android.os.Process.myPid());
+ });
+ }
}
}

diff --git a/android/app/src/test/java/com/meshchatx/AndroidStorageManagerTest.java b/android/app/src/test/java/com/meshchatx/AndroidStorageManagerTest.java
new file mode 100644
index 00000000..39f6045f
--- /dev/null
+++ b/android/app/src/test/java/com/meshchatx/AndroidStorageManagerTest.java
@@ -0,0 +1,50 @@
+package com.meshchatx;
+
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import org.junit.Assert;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.TemporaryFolder;
+
+public class AndroidStorageManagerTest {
+
+ @Rule
+ public TemporaryFolder temp = new TemporaryFolder();
+
+ @Test
+ public void hasMeshchatData_falseWhenMissingOrEmpty() throws IOException {
+ File root = temp.newFolder("meshchatx");
+ Assert.assertFalse(AndroidStorageManager.hasMeshchatData(root));
+ File storage = new File(root, "storage");
+ Assert.assertTrue(storage.mkdirs());
+ Assert.assertFalse(AndroidStorageManager.hasMeshchatData(root));
+ }
+
+ @Test
+ public void hasMeshchatData_trueWhenStorageHasFiles() throws IOException {
+ File root = temp.newFolder("meshchatx2");
+ File storage = new File(root, "storage");
+ Assert.assertTrue(storage.mkdirs());
+ try (FileOutputStream out = new FileOutputStream(new File(storage, "identity"))) {
+ out.write(1);
+ }
+ Assert.assertTrue(AndroidStorageManager.hasMeshchatData(root));
+ }
+
+ @Test
+ public void copyDirectory_copiesNestedFiles() throws IOException {
+ File src = temp.newFolder("src");
+ File nested = new File(src, "storage");
+ Assert.assertTrue(nested.mkdirs());
+ try (FileOutputStream out = new FileOutputStream(new File(nested, "db.sqlite"))) {
+ out.write(new byte[] { 1, 2, 3 });
+ }
+ File dst = temp.newFolder("dst");
+ AndroidStorageManager.copyDirectory(src, dst);
+ File copied = new File(dst, "storage/db.sqlite");
+ Assert.assertTrue(copied.isFile());
+ Assert.assertEquals(3L, copied.length());
+ }
+}

diff --git a/meshchatx/src/frontend/components/AppUpdatePrompt.vue b/meshchatx/src/frontend/components/AppUpdatePrompt.vue
new file mode 100644
index 00000000..bd4e637c
--- /dev/null
+++ b/meshchatx/src/frontend/components/AppUpdatePrompt.vue
@@ -0,0 +1,62 @@
+<!-- SPDX-License-Identifier: 0BSD -->
+
+<template>
+ <v-dialog
+ :model-value="modelValue"
+ persistent
+ :max-width="maxWidth"
+ scrollable
+ @update:model-value="$emit('update:modelValue', $event)"
+ >
+ <v-card class="bg-white dark:bg-zinc-950 border border-gray-200 dark:border-zinc-800">
+ <v-card-title class="text-lg font-semibold text-gray-900 dark:text-zinc-100 pt-5 px-5 pb-2">
+ {{ title }}
+ </v-card-title>
+ <v-card-text class="px-5 pb-2 text-sm text-gray-600 dark:text-zinc-400 space-y-3">
+ <p v-if="description">{{ description }}</p>
+ <slot />
+ <p v-if="busy && busyText" class="text-xs text-center text-gray-500 dark:text-zinc-500">
+ {{ busyText }}
+ </p>
+ </v-card-text>
+ <v-card-actions class="px-5 pb-5 pt-2 flex flex-col sm:flex-row gap-2 sm:justify-end">
+ <button
+ v-if="secondaryLabel"
+ type="button"
+ class="tutorial-action-btn tutorial-action-btn-secondary w-full sm:w-auto"
+ :disabled="busy"
+ @click="$emit('secondary')"
+ >
+ {{ secondaryLabel }}
+ </button>
+ <button
+ v-if="primaryLabel"
+ type="button"
+ class="tutorial-action-btn tutorial-action-btn-primary w-full sm:w-auto"
+ :disabled="busy || primaryDisabled"
+ @click="$emit('primary')"
+ >
+ {{ primaryLabel }}
+ </button>
+ </v-card-actions>
+ </v-card>
+ </v-dialog>
+</template>
+
+<script>
+export default {
+ name: "AppUpdatePrompt",
+ props: {
+ modelValue: { type: Boolean, default: false },
+ title: { type: String, required: true },
+ description: { type: String, default: "" },
+ primaryLabel: { type: String, default: "" },
+ secondaryLabel: { type: String, default: "" },
+ busy: { type: Boolean, default: false },
+ busyText: { type: String, default: "" },
+ primaryDisabled: { type: Boolean, default: false },
+ maxWidth: { type: [Number, String], default: 520 },
+ },
+ emits: ["update:modelValue", "primary", "secondary"],
+};
+</script>

diff --git a/meshchatx/src/frontend/components/TutorialModal.vue b/meshchatx/src/frontend/components/TutorialModal.vue
index e78f2e11..9e85a3ef 100644
--- a/meshchatx/src/frontend/components/TutorialModal.vue
+++ b/meshchatx/src/frontend/components/TutorialModal.vue
@@ -96,6 +96,66 @@
{{ $t("tutorial.migration_working") }}
</p>
</div>
+ <div
+ v-if="androidStorageSetup && androidStorageSetup.needs_setup_choice"
+ class="w-full max-w-xl mx-auto p-4 rounded-2xl border border-blue-200 dark:border-blue-900/50 bg-blue-50/90 dark:bg-blue-950/40 text-left space-y-3"
+ >
+ <div class="font-semibold text-blue-950 dark:text-blue-100">
+ {{ $t("android_storage.setup_title") }}
+ </div>
+ <p class="text-sm text-blue-950/90 dark:text-blue-100/90">
+ {{ $t("android_storage.setup_desc") }}
+ </p>
+ <label
+ class="flex items-start gap-3 p-3 rounded-xl border cursor-pointer"
+ :class="
+ androidStorageSetupChoice === 'external'
+ ? 'border-blue-500 bg-white/60 dark:bg-zinc-900/60'
+ : 'border-blue-200/60 dark:border-blue-900/40'
+ "
+ >
+ <input v-model="androidStorageSetupChoice" type="radio" class="mt-1" value="external" />
+ <span>
+ <span class="font-medium text-gray-900 dark:text-zinc-100 block">
+ {{ $t("android_storage.setup_external_title") }}
+ </span>
+ <span class="text-xs text-gray-600 dark:text-zinc-400">
+ {{ $t("android_storage.setup_external_desc") }}
+ </span>
+ </span>
+ </label>
+ <label
+ class="flex items-start gap-3 p-3 rounded-xl border cursor-pointer"
+ :class="
+ androidStorageSetupChoice === 'internal'
+ ? 'border-blue-500 bg-white/60 dark:bg-zinc-900/60'
+ : 'border-blue-200/60 dark:border-blue-900/40'
+ "
+ >
+ <input v-model="androidStorageSetupChoice" type="radio" class="mt-1" value="internal" />
+ <span>
+ <span class="font-medium text-gray-900 dark:text-zinc-100 block">
+ {{ $t("android_storage.setup_internal_title") }}
+ </span>
+ <span class="text-xs text-gray-600 dark:text-zinc-400">
+ {{ $t("android_storage.setup_internal_desc") }}
+ </span>
+ </span>
+ </label>
+ <div class="flex justify-stretch sm:justify-end">
+ <button
+ type="button"
+ class="tutorial-action-btn tutorial-action-btn-primary"
+ :disabled="androidStorageBusy || !androidStorageSetupChoice"
+ @click="applyAndroidStorageSetup"
+ >
+ {{ $t("android_storage.setup_continue") }}
+ </button>
+ </div>
+ <p v-if="androidStorageBusy" class="text-xs text-center text-gray-600 dark:text-zinc-400">
+ {{ $t("android_storage.working") }}
+ </p>
+ </div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 w-full mt-8">
<div
class="flex items-start gap-4 p-4 rounded-2xl bg-gray-50 dark:bg-zinc-900 text-left border border-gray-100 dark:border-zinc-800 transition-all hover:scale-[1.03] hover:shadow-xl hover:z-10"
@@ -1129,6 +1189,66 @@
{{ $t("tutorial.migration_working") }}
</p>
</div>
+ <div
+ v-if="androidStorageSetup && androidStorageSetup.needs_setup_choice"
+ class="w-full max-w-2xl mx-auto p-5 rounded-2xl border border-blue-200 dark:border-blue-900/50 bg-blue-50/90 dark:bg-blue-950/40 text-left space-y-3"
+ >
+ <div class="font-semibold text-blue-950 dark:text-blue-100">
+ {{ $t("android_storage.setup_title") }}
+ </div>
+ <p class="text-sm text-blue-950/90 dark:text-blue-100/90">
+ {{ $t("android_storage.setup_desc") }}
+ </p>
+ <label
+ class="flex items-start gap-3 p-3 rounded-xl border cursor-pointer"
+ :class="
+ androidStorageSetupChoice === 'external'
+ ? 'border-blue-500 bg-white/60 dark:bg-zinc-900/60'
+ : 'border-blue-200/60 dark:border-blue-900/40'
+ "
+ >
+ <input v-model="androidStorageSetupChoice" type="radio" class="mt-1" value="external" />
+ <span>
+ <span class="font-medium text-gray-900 dark:text-zinc-100 block">
+ {{ $t("android_storage.setup_external_title") }}
+ </span>
+ <span class="text-xs text-gray-600 dark:text-zinc-400">
+ {{ $t("android_storage.setup_external_desc") }}
+ </span>
+ </span>
+ </label>
+ <label
+ class="flex items-start gap-3 p-3 rounded-xl border cursor-pointer"
+ :class="
+ androidStorageSetupChoice === 'internal'
+ ? 'border-blue-500 bg-white/60 dark:bg-zinc-900/60'
+ : 'border-blue-200/60 dark:border-blue-900/40'
+ "
+ >
+ <input v-model="androidStorageSetupChoice" type="radio" class="mt-1" value="internal" />
+ <span>
+ <span class="font-medium text-gray-900 dark:text-zinc-100 block">
+ {{ $t("android_storage.setup_internal_title") }}
+ </span>
+ <span class="text-xs text-gray-600 dark:text-zinc-400">
+ {{ $t("android_storage.setup_internal_desc") }}
+ </span>
+ </span>
+ </label>
+ <div class="flex justify-stretch sm:justify-end">
+ <button
+ type="button"
+ class="tutorial-action-btn tutorial-action-btn-primary"
+ :disabled="androidStorageBusy || !androidStorageSetupChoice"
+ @click="applyAndroidStorageSetup"
+ >
+ {{ $t("android_storage.setup_continue") }}
+ </button>
+ </div>
+ <p v-if="androidStorageBusy" class="text-xs text-center text-gray-600 dark:text-zinc-400">
+ {{ $t("android_storage.working") }}
+ </p>
+ </div>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 w-full mt-12">
<div
class="flex items-start gap-6 p-6 rounded-3xl bg-gray-50 dark:bg-zinc-900 text-left border border-gray-100 dark:border-zinc-800 transition-all hover:scale-[1.03] hover:shadow-2xl hover:z-10"
@@ -2131,6 +2251,7 @@
<script>
import logoUrl from "../assets/images/logo.png";
+import AndroidStorageBridge from "../js/AndroidStorageBridge.js";
import ToastUtils from "../js/ToastUtils";
import DialogUtils from "../js/DialogUtils";
import GlobalState from "../js/GlobalState";
@@ -2186,6 +2307,10 @@ export default {
pickingRandomBootstraps: false,
migrationOffer: null,
migrationBusy: false,
+ androidStorageSetup: null,
+ androidStorageSetupChoice: "external",
+ androidStorageBusy: false,
+ androidStorageBridge: null,
};
},
computed: {
@@ -2292,6 +2417,7 @@ export default {
this.loadCommunityInterfaces();
this.loadDiscoveredInterfaces();
this.refreshMigrationOffer();
+ this.refreshAndroidStorageSetup();
this.discoveryInterval = setInterval(() => {
this.loadDiscoveredInterfaces();
}, 5000);
@@ -2430,6 +2556,7 @@ export default {
this.bootstrapCommunitySectionOpen = true;
this.bootstrapAutoPickDone = false;
await this.refreshMigrationOffer();
+ await this.refreshAndroidStorageSetup();
await this.loadIdentitySetupDefaults();
await this.loadDiscoveryBootstrapDefaults();
await this.loadCommunityInterfaces();
@@ -2492,6 +2619,45 @@ export default {
console.error("Failed to load migration status:", e);
}
},
+ ensureAndroidStorageBridge() {
+ if (!this.androidStorageBridge) {
+ this.androidStorageBridge = new AndroidStorageBridge();
+ }
+ return this.androidStorageBridge;
+ },
+ refreshAndroidStorageSetup() {
+ this.androidStorageSetup = null;
+ const bridge = this.ensureAndroidStorageBridge();
+ if (!bridge.isAndroidHost()) {
+ return;
+ }
+ const status = bridge.getStatus();
+ if (status?.needs_setup_choice) {
+ this.androidStorageSetup = status;
+ this.androidStorageSetupChoice = status.active_mode === "internal" ? "internal" : "external";
+ }
+ },
+ async applyAndroidStorageSetup() {
+ if (this.androidStorageBusy || !this.androidStorageSetup) {
+ return;
+ }
+ const bridge = this.ensureAndroidStorageBridge();
+ const mode = this.androidStorageSetupChoice || "external";
+ this.androidStorageBusy = true;
+ try {
+ const result = bridge.applySetupChoice(mode, this.androidStorageSetup);
+ if (result.restarted) {
+ ToastUtils.success(this.$t("android_storage.restart_to_apply"));
+ } else {
+ this.androidStorageSetup = null;
+ }
+ } catch (e) {
+ ToastUtils.error(this.$t("android_storage.failed"));
+ console.error(e);
+ } finally {
+ this.androidStorageBusy = false;
+ }
+ },
async migrationMigrate() {
if (this.migrationBusy || !this.migrationOffer) return;
this.migrationBusy = true;

diff --git a/meshchatx/src/frontend/js/AndroidStorageBridge.js b/meshchatx/src/frontend/js/AndroidStorageBridge.js
new file mode 100644
index 00000000..5110d14e
--- /dev/null
+++ b/meshchatx/src/frontend/js/AndroidStorageBridge.js
@@ -0,0 +1,150 @@
+/**
+ * Android app-specific storage location (internal vs external files dir).
+ */
+
+import AndroidBridge from "./rnode/AndroidBridge.js";
+
+function pickEnv() {
+ if (typeof window !== "undefined") {
+ return window;
+ }
+ if (typeof globalThis !== "undefined") {
+ return globalThis;
+ }
+ return {};
+}
+
+function safeParseJson(raw, fallback) {
+ if (raw == null || raw === "") {
+ return fallback;
+ }
+ try {
+ return JSON.parse(raw);
+ } catch {
+ return fallback;
+ }
+}
+
+export default class AndroidStorageBridge {
+ constructor(bridge = null, env = null) {
+ this.android = new AndroidBridge(bridge, env || pickEnv());
+ }
+
+ isAndroidHost() {
+ if (!this.android.isAvailable()) {
+ return false;
+ }
+ const bridge = this.android.bridge;
+ if (typeof bridge?.getPlatform === "function") {
+ try {
+ return bridge.getPlatform() === "android";
+ } catch {
+ return false;
+ }
+ }
+ return typeof bridge?.getAndroidStorageStatus === "function";
+ }
+
+ getStatus() {
+ if (!this.isAndroidHost()) {
+ return null;
+ }
+ const bridge = this.android.bridge;
+ if (typeof bridge.getAndroidStorageStatus !== "function") {
+ return null;
+ }
+ try {
+ return safeParseJson(bridge.getAndroidStorageStatus(), null);
+ } catch {
+ return null;
+ }
+ }
+
+ setMode(mode) {
+ const bridge = this.android.bridge;
+ if (typeof bridge?.setAndroidStorageMode !== "function") {
+ return false;
+ }
+ try {
+ bridge.setAndroidStorageMode(mode);
+ return true;
+ } catch {
+ return false;
+ }
+ }
+
+ scheduleCopyToExternalAndRestart() {
+ const bridge = this.android.bridge;
+ if (typeof bridge?.scheduleCopyToExternalAndRestart !== "function") {
+ return false;
+ }
+ try {
+ bridge.scheduleCopyToExternalAndRestart();
+ return true;
+ } catch {
+ return false;
+ }
+ }
+
+ keepInternalAndDismiss() {
+ const bridge = this.android.bridge;
+ if (typeof bridge?.keepInternalStorageAndDismiss !== "function") {
+ return false;
+ }
+ try {
+ bridge.keepInternalStorageAndDismiss();
+ return true;
+ } catch {
+ return false;
+ }
+ }
+
+ restartApp() {
+ const bridge = this.android.bridge;
+ if (typeof bridge?.restartApp === "function") {
+ try {
+ bridge.restartApp();
+ return true;
+ } catch {
+ // fall through
+ }
+ }
+ if (typeof bridge?.exitApp === "function") {
+ try {
+ bridge.exitApp();
+ return true;
+ } catch {
+ return false;
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Apply setup choice; restart when active mode differs from selection.
+ */
+ applySetupChoice(mode, status) {
+ if (!status) {
+ return { restarted: false };
+ }
+ const active = status.active_mode;
+ this.setMode(mode);
+ if (typeof this.android.bridge?.markAndroidStorageSetupCompleted === "function") {
+ try {
+ this.android.bridge.markAndroidStorageSetupCompleted();
+ } catch {
+ // optional on older builds
+ }
+ }
+ if (active !== mode) {
+ this.restartApp();
+ return { restarted: true };
+ }
+ return { restarted: false };
+ }
+}
+
+export function restartMeshChatAndroid() {
+ const bridge = new AndroidStorageBridge();
+ return bridge.restartApp();
+}

diff --git a/tests/frontend/AndroidStorageBridge.test.js b/tests/frontend/AndroidStorageBridge.test.js
new file mode 100644
index 00000000..7b96285b
--- /dev/null
+++ b/tests/frontend/AndroidStorageBridge.test.js
@@ -0,0 +1,43 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import AndroidStorageBridge from "../../meshchatx/src/frontend/js/AndroidStorageBridge.js";
+
+describe("AndroidStorageBridge", () => {
+ let bridge;
+
+ beforeEach(() => {
+ bridge = new AndroidStorageBridge({
+ getPlatform: () => "android",
+ getAndroidStorageStatus: () =>
+ JSON.stringify({
+ needs_setup_choice: true,
+ active_mode: "external",
+ }),
+ setAndroidStorageMode: vi.fn(),
+ markAndroidStorageSetupCompleted: vi.fn(),
+ restartApp: vi.fn(),
+ });
+ });
+
+ it("getStatus parses JSON from native bridge", () => {
+ expect(bridge.getStatus()).toEqual({
+ needs_setup_choice: true,
+ active_mode: "external",
+ });
+ });
+
+ it("applySetupChoice restarts when mode differs from active", () => {
+ const native = bridge.android.bridge;
+ const result = bridge.applySetupChoice("internal", { active_mode: "external" });
+ expect(native.setAndroidStorageMode).toHaveBeenCalledWith("internal");
+ expect(native.markAndroidStorageSetupCompleted).toHaveBeenCalled();
+ expect(native.restartApp).toHaveBeenCalled();
+ expect(result.restarted).toBe(true);
+ });
+
+ it("applySetupChoice skips restart when mode matches active", () => {
+ const native = bridge.android.bridge;
+ const result = bridge.applySetupChoice("external", { active_mode: "external" });
+ expect(native.restartApp).not.toHaveBeenCalled();
+ expect(result.restarted).toBe(false);
+ });
+});

diff --git a/tests/frontend/AndroidStorageChoicePrompt.test.js b/tests/frontend/AndroidStorageChoicePrompt.test.js
new file mode 100644
index 00000000..6254369f
--- /dev/null
+++ b/tests/frontend/AndroidStorageChoicePrompt.test.js
@@ -0,0 +1,68 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { mount } from "@vue/test-utils";
+import { createI18n } from "vue-i18n";
+import { createVuetify } from "vuetify";
+import AndroidStorageChoicePrompt from "../../meshchatx/src/frontend/components/AndroidStorageChoicePrompt.vue";
+import en from "../../meshchatx/src/frontend/locales/en.json";
+
+const scheduleCopyToExternalAndRestart = vi.fn(() => true);
+const keepInternalAndDismiss = vi.fn(() => true);
+const restartApp = vi.fn(() => true);
+const getStatus = vi.fn(() => ({
+ needs_upgrade_prompt: true,
+ active_path: "/data/user/0/com.meshchatx/files/meshchatx",
+}));
+
+vi.mock("../../meshchatx/src/frontend/js/AndroidStorageBridge.js", () => ({
+ default: class MockAndroidStorageBridge {
+ isAndroidHost() {
+ return true;
+ }
+
+ getStatus() {
+ return getStatus();
+ }
+
+ scheduleCopyToExternalAndRestart() {
+ return scheduleCopyToExternalAndRestart();
+ }
+
+ keepInternalAndDismiss() {
+ return keepInternalAndDismiss();
+ }
+
+ restartApp() {
+ return restartApp();
+ }
+ },
+}));
+
+const i18n = createI18n({ legacy: false, locale: "en", messages: { en } });
+const vuetify = createVuetify();
+
+describe("AndroidStorageChoicePrompt", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("showUpgrade opens dialog when status requires prompt", async () => {
+ const wrapper = mount(AndroidStorageChoicePrompt, {
+ props: { variant: "upgrade" },
+ global: { plugins: [i18n, vuetify] },
+ });
+ expect(wrapper.vm.showUpgrade()).toBe(true);
+ await wrapper.vm.$nextTick();
+ expect(wrapper.vm.visible).toBe(true);
+ });
+
+ it("primary action schedules copy and restarts", async () => {
+ const wrapper = mount(AndroidStorageChoicePrompt, {
+ props: { variant: "upgrade" },
+ global: { plugins: [i18n, vuetify] },
+ });
+ wrapper.vm.showUpgrade();
+ await wrapper.vm.onPrimary();
+ expect(scheduleCopyToExternalAndRestart).toHaveBeenCalled();
+ expect(restartApp).toHaveBeenCalled();
+ });
+});


──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────